make run
起來目標:把資料庫「裝在箱子裡」,本機不用額外安裝。
名詞小辭典:Docker 就像可攜式小電腦,把服務裝進去,隨叫隨用。
建立 docker-compose.yml
:
version: "3.9"
services:
postgres:
image: postgres:16-alpine
container_name: blog-postgres
environment:
POSTGRES_DB: blog
POSTGRES_USER: blog
POSTGRES_PASSWORD: blogpass
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U blog -d blog"]
interval: 5s
timeout: 5s
retries: 10
volumes:
pgdata:
啟動:
docker compose up -d
驗證(看健康狀態,等到 healthy):
docker ps
.env
,讓 App 知道怎麼連重點:DB_DSN
是「資料庫連線字串」。
更新 .env.example
,然後 cp
成 .env
:
cat > .env.example << 'EOF'
APP_ENV=development
PORT=1323
SITE_NAME=My Echo Blog
# Postgres 連線字串(DSN)
DB_DSN=postgres://blog:blogpass@localhost:5432/blog?sslmode=disable
DB_MAX_CONNS=10
DB_MIN_CONNS=2
DB_MAX_LIFETIME=30m
DB_MAX_IDLE_TIME=5m
EOF
cp .env.example .env
名詞小辭典:goose 幫你用檔案管理資料表變更,換機器也能一鍵復原。
go install github.com/pressly/goose/v3/cmd/goose@latest
goose --version
建立 migrations/20251002090000_init_schema.up.sql
:
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'author',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS posts (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
summary TEXT,
content_md TEXT NOT NULL,
cover_image TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tags (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS post_tags (
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_posts_status_published_at ON posts (status, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts (slug);
CREATE INDEX IF NOT EXISTS idx_tags_name ON tags (name);
建立 migrations/20251002090000_init_schema.down.sql
:
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS post_tags;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS users;
執行遷移:
goose -dir ./migrations postgres "$DB_DSN" up
pgxpool
連線(App ↔ DB 接起來)新增 internal/storage/postgres/db.go
:
package postgres
import (
"context"
"fmt"
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct{ Pool *pgxpool.Pool }
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" { return v }
return def
}
func parseDuration(s, def string) time.Duration {
if s == "" { d,_ := time.ParseDuration(def); return d }
d, err := time.ParseDuration(s); if err != nil { d,_ = time.ParseDuration(def) }
return d
}
func parseInt(s string, def int) int {
if s == "" { return def }
n, err := strconv.Atoi(s); if err != nil { return def }
return n
}
func Connect(ctx context.Context) (*DB, error) {
dsn := getenv("DB_DSN", "")
if dsn == "" { return nil, fmt.Errorf("missing DB_DSN") }
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil { return nil, fmt.Errorf("parse dsn: %w", err) }
cfg.MaxConns = int32(parseInt(getenv("DB_MAX_CONNS","10"),10))
cfg.MinConns = int32(parseInt(getenv("DB_MIN_CONNS","2"),2))
cfg.MaxConnLifetime = parseDuration(getenv("DB_MAX_LIFETIME","30m"),"30m")
cfg.MaxConnIdleTime = parseDuration(getenv("DB_MAX_IDLE_TIME","5m"),"5m")
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil { return nil, fmt.Errorf("new pool: %w", err) }
ctxPing, cancel := context.WithTimeout(ctx, 5*time.Second); defer cancel()
if err := pool.Ping(ctxPing); err != nil {
pool.Close(); return nil, fmt.Errorf("ping db: %w", err)
}
return &DB{Pool: pool}, nil
}
func (d *DB) Close() { if d != nil && d.Pool != nil { d.Pool.Close() } }
/db/health
路由(健康檢查)新增 internal/http/handlers/db.go
:
package handlers
import (
"context"
"net/http"
"time"
"github.com/labstack/echo/v4"
)
func DBHealthHandler(ping func(ctx context.Context) error) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
defer cancel()
if err := ping(ctx); err != nil {
return c.JSON(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{
"ok": true,
"db": "up",
"now": time.Now().Format(time.RFC3339),
})
}
}
更新 cmd/server/main.go
(掛上 DB 與路由):
package main
import (
"context"
"html/template"
"io"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"example.com/go-echo-blog/internal/http/handlers"
pgstore "example.com/go-echo-blog/internal/storage/postgres"
)
type TemplateRenderer struct{ t *template.Template }
func (tr *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error {
return tr.t.ExecuteTemplate(w, name, data)
}
func getenv(k, d string) string { if v:=os.Getenv(k); v!="" {return v}; return d }
func main() {
_ = godotenv.Load()
port := getenv("PORT", "1323")
e := echo.New()
e.Use(middleware.Recover(), middleware.Logger(), middleware.CORS())
e.Static("/static", "web/static")
t := template.Must(template.ParseGlob("web/templates/*.html"))
e.Renderer = &TemplateRenderer{t: t}
ctx := context.Background()
db, err := pgstore.Connect(ctx)
if err != nil { log.Fatalf("connect db: %v", err) }
defer db.Close()
e.GET("/", handlers.HomeHandler)
e.GET("/health", handlers.HealthHandler)
e.GET("/_ping", func(c echo.Context) error { return c.String(http.StatusOK, "pong") })
e.GET("/db/health", handlers.DBHealthHandler(func(ctx context.Context) error { return db.Pool.Ping(ctx) }))
go func() {
log.Printf("Server on :%s 🚦", port)
if err := e.Start(":" + port); err != nil && err != http.ErrServerClosed { log.Fatal(err) }
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.Shutdown(ctxShutdown); err != nil { log.Printf("server shutdown: %v", err) }
}
把下列目標加進你的 Makefile
(或直接替換):
APP_NAME=go-echo-blog
.PHONY: run dev tidy fmt clean db-up db-down migrate up down status
run:
@go run ./cmd/server
dev:
@go run ./cmd/server
tidy:
@go mod tidy
fmt:
@gofmt -w .
clean:
@rm -f $(APP_NAME)
db-up:
@docker compose up -d
db-down:
@docker compose down
migrate:
@goose -dir ./migrations postgres "$$DB_DSN" up
up: migrate
down:
@goose -dir ./migrations postgres "$$DB_DSN" down
status:
@goose -dir ./migrations postgres "$$DB_DSN" status
(如果你喜歡一次複製,下面是第 2 篇所有新增/更新檔案的「合併版」。)
docker-compose.yml
、migrations/*.sql
、internal/storage/postgres/db.go
、internal/http/handlers/db.go
、cmd/server/main.go
與 Makefile
:請從上面各段落複製對應內容到你的專案。
make db-up
goose -dir ./migrations postgres "$DB_DSN" up
make run
curl -s http://localhost:1323/db/health | jq .
# 期待:
# { "ok": true, "db": "up", "now": "2025-10-02T10:xx:xx+08:00" }
curl -i http://localhost:1323/
curl -s http://localhost:1323/health | jq .
curl -s http://localhost:1323/_ping
connect db: missing DB_DSN
:.env
沒設好,或路徑不在專案根目錄。connection refused
:Postgres 沒起來;make db-up
,再 docker ps
看健康狀態。docker-compose.yml
與 .env
的使用者/密碼/DB 名要一致。-dir ./migrations
與 DB_DSN
,以及檔名時間戳格式。docker compose down -v && docker compose up -d
(會刪資料,注意)。恭喜把 DB 打通了!🎉 現在我們有 Docker 化的 Postgres、pgxpool
連線、goose
管理 schema,還能用 /db/health
自我檢查。
第 3 篇預告:模板與靜態資源——加上 html/template
的 layout/partial、Tailwind CDN,讓首頁不再素顏。
加分作業:插入一個 admin 使用者與 2 篇假文章,之後列表與分頁就能用到;也可以把 Makefile
加上 seed
指令,養成好習慣 😄